package bsh;

import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.objectweb.asm.MethodVisitor;

import bsh.compilation.ReflectionUtilsImpl;
import bsh.dyn.Callsite;
import bsh.dyn.DeclaredParameterType;
import bsh.dyn.IndyResolver;
import bsh.dyn.Invocable;
import bsh.dyn.InvocationTargetImpl;
import bsh.exception.CompilationFailedException;
import bsh.meta.JavaClassWrapper;
import bsh.meta.MethodDescriptor;
import bsh.meta.TypeWrapper;

/**
 * Utility class that implement most of a {@link CompiledScript} funcionality
 * 
 * @author takeshi
 */
public final class CompiledScriptHelper {

    /**
     * private constructor
     */
    private CompiledScriptHelper() {
        throw new IllegalArgumentException("cannot be instantiated");
    }

    public static final Class<?>[] EMPTY_CLASS_ARRAY = new Class<?>[]{};

    public static final Object[] EMPTY_OBJECT_ARRAY = new Object[]{};

    private static final MethodDescriptor ARRAY_LENGTH;
    static {
        ReflectionUtils utils = new ReflectionUtilsImpl();
        ARRAY_LENGTH =
                utils.findMethodForTypes(JavaClassWrapper.wrapperFor(Array.class), "getLength",
                        new TypeWrapper[]{ JavaClassWrapper.OBJECT_WRAPPER });
    }

    public static final Invocable ARRAY_LENGTH_METHOD_INVOCATION = new Invocable() {

        public Object invoke(CompiledScript script, Object callTarget, Object[] params) throws Throwable {
            return Array.getLength(callTarget);
        }

        public DeclaredParameterType[] getParameterTypes() {
            return new DeclaredParameterType[]{};
        }

        public boolean isVarargs() {
            return false;
        }

        public boolean isCompilable() {
            return true;
        }

        public void generateBytecode(MethodVisitor visitor, CompilationUnit unit, CompilationState state) throws CompilationFailedException {
            ARRAY_LENGTH.writeInvoke(visitor, state);
        }
    };

    /**
     * Attempts to dynamicaly invoke a method on a given target, given the method's name and arguments. If successfull, injects the callsite
     * into the {@link CompiledScript#getInvocationCache()}. Follows the same rules as the java method invocation resolution.
     * 
     * @param invocationTarget the receiver of the method. May be a {@link Class} if the invoking method is static
     * @param callsiteId the (uniquely) generated callsite id. Will be used as part of the key to the callsite cache
     * @param methodName the method name
     * @param args the arguments
     * @param script the script
     * @return the method return value if any
     * @throws Throwable e
     */
    public static Object invokeDynamic(Object invocationTarget, int callsiteId, String methodName, Object[] args, CompiledScript script)
            throws Throwable {
        Callsite callsite = toCallSite(callsiteId, invocationTarget, args);

        Invocable invocable = script.getInvocationCache().get(callsite);
        if (invocable == null) {
            invocable = IndyResolver.findBestMethod(methodName, new InvocationTargetImpl(callsite.getReceiving()), args);
            if (invocable != null) {
                // JITCompiler comp = new JITCompilerImpl((OpenClassLoader) script.getClass().getClassLoader());
                // invocable = comp.compileInvocable(invocable);
                script.getInvocationCache().put(callsite, invocable);
            } else if (invocationTarget instanceof Class) {
                invocable = IndyResolver.findBestStaticMethod(methodName, new InvocationTargetImpl((Class<?>) invocationTarget), args);
                if (invocable != null) {
                    script.getInvocationCache().put(callsite, invocable);
                } else {
                    throw new IllegalArgumentException("cannot find method named " + methodName + " on type: " + invocationTarget
                            + " for arg types: " + Arrays.toString(callsite.getParams()));
                }
            } else if (invocationTarget != null && isInnerClass(invocationTarget.getClass())) {
                invocable = selectInvocableForInnerClass(invocationTarget, methodName, args, script);
                return invocable.invoke(script, invocationTarget, args);
            }
        }
        if (invocable != null) {
            script.getInvocationCache().put(callsite, invocable);
            return invocable.invoke(script, invocationTarget, args);
        } else {
            throw new IllegalArgumentException("cannot find method named " + methodName + " on type: " + callsite.getReceiving()
                    + " for arg types: " + Arrays.toString(callsite.getParams()));
        }
    }

    /**
     * Selects an {@link Invocable} for an inner class instance. Looks through outer class instances to find methods
     * 
     * @param invocationTarget {@link Object}
     * @param methodName {@link String}
     * @param args Object[] args
     * @param script {@link CompiledScript}
     * @return {@link Invocable} or <code>null</code>
     */
    private static Invocable selectInvocableForInnerClass(Object invocationTarget, String methodName, Object[] args, CompiledScript script) {
        Field field = getEnclosingInstanceField(invocationTarget.getClass());
        try {
            Object enclosingInstance = field.get(invocationTarget);
            if (enclosingInstance == null) {
                throw new NullPointerException();
            }
            Invocable innerInvocable =
                    IndyResolver.findBestMethod(methodName, new InvocationTargetImpl(enclosingInstance.getClass()), args);
            if (innerInvocable != null) {
                return new InnerClassInvocableWrapper(innerInvocable, field);
            }
            if (isInnerClass(enclosingInstance.getClass())) {
                return new InnerClassInvocableWrapper(selectInvocableForInnerClass(enclosingInstance, methodName, args, script), field);
            }
        } catch (IllegalAccessException e) {
            throw new IllegalArgumentException("Cannot access enclosing instance for " + invocationTarget);
        }
        return null;
    }

    /**
     * Finds the {@link Field} that contains the outer class instance
     * 
     * @param class1 {@link Class}
     * @return {@link Field}
     */
    private static Field getEnclosingInstanceField(Class<? extends Object> class1) {
        try {
            if (class1 == null) {
                throw new IllegalArgumentException("Cannot find inner enclosing instance field on type: " + class1);
            }
            Field f = class1.getDeclaredField("this$0");
            f.setAccessible(true);
            return f;
        } catch (NoSuchFieldException e) {
            return getEnclosingInstanceField(class1.getSuperclass());
        }
    }

    /**
     * Wrapps an {@link Invocable} to call a method on an outer instance field
     * 
     * @author takeshi
     */
    private static class InnerClassInvocableWrapper implements Invocable {

        private final Invocable invocable;

        private transient Field field;

        /**
         * C'tor
         * 
         * @param invocable {@link Invocable}
         * @param field {@link Field}
         */
        public InnerClassInvocableWrapper(Invocable invocable, Field field) {
            super();
            this.invocable = invocable;
            this.field = field;
        }

        /**
         * {@inheritDoc}
         */
        public Object invoke(CompiledScript script, Object target, Object[] parameters) throws Throwable {
            if (target == null) {
                throw new NullPointerException();
            }
            if (this.field == null) {
                this.field = getEnclosingInstanceField(target.getClass());
            }
            Object otherTarget = this.field.get(target);
            return invocable.invoke(script, otherTarget, parameters);
        }

        /**
         * {@inheritDoc}
         */
        public void generateBytecode(MethodVisitor visitor, CompilationUnit unit, CompilationState state) throws CompilationFailedException {
        }

        /**
         * {@inheritDoc}
         */
        public DeclaredParameterType[] getParameterTypes() {
            return invocable.getParameterTypes();
        }

        /**
         * {@inheritDoc}
         */
        public boolean isVarargs() {
            return invocable.isVarargs();
        }

        /**
         * {@inheritDoc}
         */
        public boolean isCompilable() {
            return false;
        }

    }

    /**
     * Verifies if the given {@link Class} is an inner class
     * 
     * @param class1 the class
     * @return boolean
     */
    private static boolean isInnerClass(Class<? extends Object> class1) {
        if (class1 == null) {
            return false;
        }
        if (class1.getEnclosingClass() != null) {
            return true;
        }
        try {
            class1.getDeclaredField("this$0");
        } catch (NoSuchFieldException e) {
            return isInnerClass(class1.getSuperclass());
        }
        return true;
    }

    /**
     * Creates a new {@link Callsite} object, to be used as a {@link Callsite} cache key.
     * 
     * @param callsiteId the id
     * @param invocationTarget the invocation target
     * @param args the arguments
     * @return {@link Callsite}
     */
    private static Callsite toCallSite(int callsiteId, Object invocationTarget, Object[] args) {
        Class<?>[] collected = new Class<?>[args.length];
        for (int i = 0; i < collected.length; i++) {
            collected[i] = args[i] == null ? null : args[i].getClass();
        }

        Class<? extends Object> class1;
        if (invocationTarget == null) {
            class1 = Object.class;
        } else {
            class1 = (invocationTarget instanceof Class<?>) ? (Class<?>) invocationTarget : invocationTarget.getClass();
        }
        Callsite callsite = new Callsite(callsiteId, class1, collected);
        return callsite;
    }

    /**
     * Performs an unary operation on the given operand
     * 
     * @param operand Object
     * @param id int
     * @param kind int
     * @param thisInstance instancia
     * @return Object
     */
    public static Object performUnaryOperation(Object operand, int id, int kind, CompiledScript thisInstance) {
        return doPerformUnaryOperation(id, kind, operand, thisInstance.getMathContext());
    }

    /**
     * Performs a unary operation
     * 
     * @param id int
     * @param kind int
     * @param target Object
     * @param context the {@link MathContext}
     * @return Object
     */
    public static Object doPerformUnaryOperation(int id, int kind, Object target, MathContext context) {
        return PrimitiveUtils.unaryOperation(target, kind, context);
    }

    /**
     * Performs a binary operation on the given operands.
     * 
     * @param left Object
     * @param right Object
     * @param id int
     * @param kind int
     * @param thisInstance {@link Prototype}
     * @return Object
     */
    public static Object performBinaryOperation(Object left, Object right, int id, int kind, CompiledScript thisInstance) {
        return doPerformBinaryOperation(id, kind, left, right, thisInstance.getMathContext());
    }

    /**
     * Performs a binary operation on the given operands.
     * 
     * @param id int
     * @param kind int
     * @param left Object
     * @param right Object
     * @param context the {@link MathContext}
     * @return Object
     */
    public static Object doPerformBinaryOperation(int id, int kind, Object left, Object right, MathContext context) {
        return PrimitiveUtils.binaryOperation(left, right, kind, context);
    }

    /**
     * Converts a double to a bigDecimal, first calling {@link String#valueOf(double)} and them {@link BigDecimal#BigDecimal(String)}
     * 
     * @param value double
     * @param theThis the Prototype
     * @return {@link BigDecimal}
     */
    public static BigDecimal toBigDecimal(double value, CompiledScript theThis) {
        return theThis.localToBigDecimal(value);
    }

    /**
     * Sets all the field values on the given {@link CompiledScript}. Null values resets the field values.
     * 
     * @param compiledScript the {@link CompiledScript}
     * @param map the field values
     */
    public static void setFields(CompiledScript compiledScript, Map<String, Object> map) {
        for (Entry<String, Object> entry : map.entrySet()) {
            compiledScript.setField(entry.getKey(), entry.getValue());
        }
    }

    /**
     * Resets all fields on the given {@link CompiledScript}
     * 
     * @param compiledScript {@link CompiledScript}
     */
    public static void resetAllFields(CompiledScript compiledScript) {
        compiledScript.clearDynamicFields();
        for (Field field : compiledScript.getClass().getFields()) {
            if (isInstanceAssignable(field)) {
                try {
                    field.set(compiledScript, PrimitiveUtils.getDefaultValue(field.getType()));
                } catch (IllegalAccessException e) {
                    throw new IllegalStateException(e);
                }
            }
        }
    }

    /**
     * Resets a field value
     * 
     * @param compiledScript the {@link CompiledScript}
     * @param name the field name
     */
    public static void resetField(CompiledScript compiledScript, String name) {
        try {
            Field field = compiledScript.getClass().getField(name);
            if (isInstanceAssignable(field)) {
                Object val = PrimitiveUtils.getDefaultValue(field.getType());
                field.set(compiledScript, val);
            } else {
                compiledScript.setDynamicField(name, null);
            }
        } catch (NoSuchFieldException e) {
            compiledScript.setDynamicField(name, null);
        } catch (IllegalAccessException e) {
            throw new IllegalStateException(e);
        }
    }

    /**
     * Sets a field value if possible. Otherwise, sets a dynamic field with the given value
     * 
     * @param compiledScript the {@link CompiledScript} instance
     * @param name the field name
     * @param value the field value
     */
    public static void setField(CompiledScript compiledScript, String name, Object value) {
        if (value == null) {
            compiledScript.resetField(name);
        } else {
            try {
                Field field = compiledScript.getClass().getField(name);
                if (isInstanceAssignable(field)) {
                    field.set(compiledScript, value);
                } else {
                    compiledScript.setDynamicField(name, value);
                }
            } catch (NoSuchFieldException e) {
                compiledScript.setDynamicField(name, value);
            } catch (IllegalArgumentException e) {
                compiledScript.setDynamicField(name, value);
            } catch (IllegalAccessException e) {
                compiledScript.setDynamicField(name, value);
            }
        }
    }

    /**
     * Gets a field value
     * 
     * @param compiledScript the compiledScript
     * @param name the field name
     * @return Object the value of the field. May return <code>null</code>.
     */
    public static Object getFieldValue(CompiledScript compiledScript, String name) {
        try {
            Field field = compiledScript.getClass().getField(name);
            if (isPublic(field)) {
                return field.get(compiledScript);
            }
            return compiledScript.getDynamicField(name);
        } catch (NoSuchFieldException e) {
            return compiledScript.getDynamicField(name);
        } catch (SecurityException e) {
            return compiledScript.getDynamicField(name);
        } catch (IllegalAccessException e) {
            return compiledScript.getDynamicField(name);
        }
    }

    /**
     * Returns true if the given field is public
     * 
     * @param field {@link Field}
     * @return boolean
     */
    private static boolean isPublic(Field field) {
        return Modifier.isPublic(field.getModifiers());
    }

    /**
     * Gets a field or a property from a given object - where property refers to a javabean style property - getter and setter.
     * 
     * @param target the target
     * @param field the field or property name
     * @param callsiteId the callsite id, to assign to a cache
     * @param script the {@link CompiledScript}
     * @return the field or property value. May return be null if the underlying field or property is null.
     * @throws Throwable e
     */
    public static Object getFieldOrProperty(Object target, String field, int callsiteId, CompiledScript script) throws Throwable {
        if (target == null) {
            throw new NullPointerException("target is null");
        }
        if (field == null || "".equals(field)) {
            throw new IllegalArgumentException("Field name cannot be null");
        }
        Map<Callsite, Invocable> cache = script.getInvocationCache();
        Class<? extends Object> type = (target instanceof Class<?>) ? (Class<?>) target : target.getClass();
        try {
            Field f = type.getField(field);
            return f.get(target);
        } catch (NoSuchFieldException e) {
            // must try a property name
            Callsite callsite = new Callsite(callsiteId, type, CompiledScriptHelper.EMPTY_CLASS_ARRAY);
            Invocable inv = cache.get(callsite);
            if (inv != null) {
                return inv.invoke(script, target, CompiledScriptHelper.EMPTY_OBJECT_ARRAY);
            }
            // must calculate things. First well try a getField
            final String suffix = Character.toUpperCase(field.charAt(0)) + field.substring(1);
            String getter = "get" + suffix;
            InvocationTargetImpl invocationTarget = new InvocationTargetImpl(type);
            inv = IndyResolver.findBestMethod(getter, invocationTarget, EMPTY_OBJECT_ARRAY);
            if (inv != null) {
                cache.put(callsite, inv);
                return inv.invoke(script, target, CompiledScriptHelper.EMPTY_OBJECT_ARRAY);
            }
            getter = "is" + suffix;
            inv = IndyResolver.findBestMethod(getter, invocationTarget, EMPTY_OBJECT_ARRAY);
            if (inv != null) {
                cache.put(callsite, inv);
                return inv.invoke(script, target, CompiledScriptHelper.EMPTY_OBJECT_ARRAY);
            }
            if (type.isArray() && "length".equals(field)) {
                inv = CompiledScriptHelper.ARRAY_LENGTH_METHOD_INVOCATION;
                cache.put(callsite, inv);
                return inv.invoke(script, target, CompiledScriptHelper.EMPTY_OBJECT_ARRAY);
            }
            // i wonder if we should try to easy .size on collections as well
            throw new IllegalArgumentException("no field named: " + field + " found on class: " + type);
        }
    }

    /**
     * Attempts to set a field or a property on a given object - where property refers to a javabean style property
     * 
     * @param target the target
     * @param field the field or property name
     * @param callsiteId the callsite id
     * @param arg the value to set the field or property to
     * @param script the {@link CompiledScript}
     * @throws Throwable e
     */
    public static void setFieldOrProperty(Object target, String field, int callsiteId, Object arg, CompiledScript script) throws Throwable {
        if (target == null) {
            throw new NullPointerException("target is null");
        }
        if (field == null || "".equals(field)) {
            throw new IllegalArgumentException("Field name cannot be null");
        }
        Class<? extends Object> type = (target instanceof Class<?>) ? (Class<?>) target : target.getClass();
        try {
            Field f = type.getField(field);
            f.set(target, arg);
            return;
        } catch (NoSuchFieldException e) {
            String setter = "set" + (Character.toUpperCase(field.charAt(0)) + field.substring(1));
            invokeDynamic(target, callsiteId, setter, new Object[]{ arg }, script);
        }
    }

    /**
     * Returns an {@link Iterator} to iterate over the value of the given {@link Object}. In case the given object is a map, return an
     * iterator over the entry set of the map
     * 
     * @param obj the object to iterate over
     * @return {@link Iterator}
     */
    public static Iterator<?> getIterator(final Object obj) {
        if (obj == null) {
            throw new NullPointerException("cannot iterate over a null");
        }
        if (obj.getClass().isArray()) {
            return new Iterator() {

                private int current;

                public boolean hasNext() {
                    return current < Array.getLength(obj);
                }

                public Object next() {
                    return Array.get(obj, current++);
                }

                public void remove() {
                    throw new UnsupportedOperationException("cannot remove from an array");
                }
            };
        } else if (obj instanceof Iterable<?>) {
            return ((Iterable<?>) obj).iterator();
        } else if (obj instanceof Map<?, ?>) {
            return ((Map<?, ?>) obj).entrySet().iterator();
        }
        throw new IllegalArgumentException("cannot iterate over an object of type: " + obj.getClass());
    }

    /**
     * Verifica se o field eh modificavel
     * 
     * @param field Field
     * @return boolean
     */
    public static boolean isInstanceAssignable(Field field) {
        return field != null && !Modifier.isFinal(field.getModifiers()) && !Modifier.isStatic(field.getModifiers());
    }

    /**
     * Attempts to create an object. Uses the same rules for constructor resolution as
     * {@link #invokeDynamic(Object, int, String, Object[], CompiledScript)}
     * 
     * @param callsiteId the callsite id
     * @param type the type to be instantiated
     * @param args the constructor arguments
     * @param script the {@link CompiledScript}
     * @return the newly constructed object. May never be null
     * @throws Throwable e
     */
    public static Object createObject(int callsiteId, Class<?> type, Object[] args, CompiledScript script) throws Throwable {
        Callsite callsite = toCallSite(callsiteId, type, args);
        Invocable inv = script.getInvocationCache().get(callsite);
        if (inv == null) {
            inv = IndyResolver.findBestConstructor(new InvocationTargetImpl(type), args);
            if (inv == null) {
                throw new IllegalArgumentException("cannot find constructor on type: " + type + " for arg types: "
                        + Arrays.toString(callsite.getParams()));
            }
            script.getInvocationCache().put(callsite, inv);
        }
        return inv.invoke(script, null, args);
    }

    /**
     * Called whenever a method is invoked
     * 
     * @param script the script
     * @param scriptName the name of the script
     * @param targetType the target type (of the method invocation)
     * @param methodName the method
     * @param declaredArgumentCount number of arguments that the method receives
     * @param actualArgumentCount the number of arguments that was passed
     * @param staticallyResolved true if the method was statically resolved
     * @param lineNumber the line number (on the script) that called the method
     */
    public static void registerMethodInvocation(Object script, String scriptName, Class<?> targetType, String methodName,
            int declaredArgumentCount, int actualArgumentCount, boolean staticallyResolved, int lineNumber) {
        final Log log = LogFactory.getLog(script.getClass());
        if (log.isDebugEnabled()) {
            log.debug(String.format("Script %s called method %s on type %s with %d arguments on line %d", scriptName, methodName,
                    targetType.getName(), actualArgumentCount, lineNumber));
        }
    }

}
